fix(automation): publish $error on the throw arm before deciding whether the failure routes - #16302
Conversation
…ether the failure routes The engine's returned-failure arm rewrites the run-wide `$error` and `<nodeId>.error` unconditionally, and only then asks whether a `fault` edge may route the failure. The throw arm did both inside `if (faultEdge)`, so a thrown failure with no fault edge of its own left `$error` naming an earlier, unrelated failure — and a node inside a structured region never has a fault edge of its own, because the region's synthetic sub-flow carries only the region's own edges. The message and the code came from two different failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…ror-refresh-asymmetry
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1bf2875df31def2b0444f79bf99a26f9c95af87a && git checkout 1bf2875df31def2b0444f79bf99a26f9c95af87a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7beaaa32ccb1295eeaec73bdca4aea36e713232d 577a51e152bc333352443040c56495382c88892f && git checkout -B drift-repro 7beaaa32ccb1295eeaec73bdca4aea36e713232d && git merge --no-ff 577a51e152bc333352443040c56495382c88892f
node scripts/docs-audit/affected-docs.mjs --json 7beaaa32ccb1295eeaec73bdca4aea36e713232d
|
PM 复核 —— PASS在 head 边界守住了 —— 第 2 步没有被顺手做掉派单把本卡拆成两步,只有第 1 步可做;第 2 步(改引擎的 throw 形状)必须停下拆卡送决策箱。我核了: ⭐ 第 1 步的答案,以及它比「不对称」更硬的地方卡片的判据是:若有理由,那个理由没写在现场,应当写上;若没有,那这个不对称本身就是缺陷。 席位答的是没有理由,三条读数。其中第 (c) 条我到源头核了,它才是真正的判据: ⇒ 已发布的契约本来就写着「最近一次失败」。 那么条件式刷新违反的不是对称性偏好,而是一条我们对外发布的契约。这把本卡从「一个可能有人替它辩护的不对称」变成了「代码与我们公开声明的东西不一致」—— 后者不需要任何裁决。 另外两条也成立且互相加强:(a) 两条臂同一次批量提交
|
Fixes #14955
Step 1 answered: there is no reason, so the asymmetry is the defect
The card set the criterion itself — if there is a reason, it is not recorded at the site and should be; if there is not, the asymmetry is the defect. Three independent readings say there is not:
cc8484224("enhance automation engine with fault edges, step logging, timeout, ..."), and the asymmetry was there at birth. The returned-failure arm was written with its own comment,// Write error output to variable context for downstream nodes, placed above the fault-edge lookup. The throw arm has only// Check for fault edges, with the write inside it. Two adjacent blocks in one bulk commit, written inconsistently — not a decision.loopdiscards its body's completed steps wholesale — the run summary reportsacted: 0while 5 writes actually happened (childSteps splice only after a SUCCESSFUL node result) #13803 /try_catchwith nocatchregion discards its try-region step record on the returned-failure path (the other half of the engine's childSteps asymmetry) #14184's step folds, automation:create_recordcollapses the engine'sDUPLICATE_RECORDenvelope to a string, so a flow'stry_catch/faultedge still cannot tell "already there" from "the store is down" #14419'scode) states in its own comment that it leaves$errorsemantics untouched. No ADR, no doc and no test records a rationale for the conditional; no test pins the absence of the write.content/docs/automation/flows.mdxdocuments{$error}as naming "the most recent failure only". The conditional violated the contract that was already written down.So this is the "asymmetry is the defect" branch of the card, and the fix is the one the claim comment named: make the throw arm's refresh symmetric with the returned-failure arm's. The throw shape is untouched —
execErris rethrown exactly as caught, no exported symbol, signature or payload key moves. The wider alternative the card records (attachcodeto the thrownError, havetry_catchread it off the caught exception) is not taken here.What changed
executeNode's throw arm now publishes$errorand the node-scopedNODE_ID.errorentry before deciding whether the failure routes, exactly as the returned-failure arm does. Publishing is not routing: the #3863 guard-refusal rule below the write is untouched and still decides, alone, which failures afaultedge may carry.A thrown failure publishes
{ nodeId, message }and nothing more. There is noNodeExecutionResulton that path, so nooutputand no classifiedcodeexist to carry — and that absence is the correct answer for a throw, rather than a reason to leave a stalecodestanding.The rationale is now recorded at the site, which was the other half of what the card asked for either way.
Why it mattered, in the card's own words
A node inside a structured region never has a
faultedge of its own — the region's synthetic sub-flow carries only the region's own edges (runRegion). So every thrown failure inside atry_catch, aloopbody or any other region left$errornaming an earlier, unrelated failure. The failure mode is not a crash but a plausible-looking wrong value:{ code: 'DUPLICATE_RECORD', message: "Node 'mk' timed out after 20ms" }, swallowed by a catch region reading it as "the row is already there", with the run reporting success. That is exactly why it stayed invisible, and why the pins below drive flows to different edges rather than asserting a variable.Pins — three flows, driven end to end
packages/services/service-automation/src/throw-arm-error-refresh.test.ts:$error. An earlier fault-routedcreate_recordduplicate seeds a stale$error; atry_catchbinding to$caught(deliberately not$error) then lets its catch region read the engine's own run-wide variable after a node inside the try region throws. Red before, green after.$erroragainst what the attempt started with, so a rewrite happening inside the attempt window — here an innertry_catchbinding its own swallowed duplicate — passes the identity check, and the outer container boundDUPLICATE_RECORDonto a timeout's message and swallowed it. The two outcomes take different edges (reraisedthenescalate, versus a silent swallow). Red before, green after.$errorseeded first, so the new unconditional write is actually exercised on that path: the run still aborts with the guard's own message, the handler behind thefaultedge never runs, and the destructive verb is never reached. Green before and after — a guard-rail, not a red-first pin, and stated as such.On PR #14948 — not re-litigated
That PR's minimal identity guard and its two flow pins are left in place and unchanged. Its guard now has a narrower job rather than none: a throw that is not a node failure at all (a durable pause refused inside a region, a missing region entry — anything
runRegionraises before reaching a node) still never touches$error, and the stale value must not be claimed then either. Two comments that described the old engine behaviour and became false with this change are corrected in place, without touching a single assertion.Also unrelated to issue #15646 (same file, undispatched): nothing here touches
runRegionor the pause path, andorigin/maincarried nopackages/services/service-automation/change between this branch's base and its merge of current main.Verification — all on the final head
577a51e15if (faultEdge)should turn pins 1 and 2 red on the same two assertions and leave pin 3 green. Observed exactly that:2 failed | 1 passed,expected 'a' to be 'boom'andexpected 'DUPLICATE_RECORD' to be undefined. The mutation was confirmed on disk before the run (the 16-space spelling went 1 to 0, the 20-space spelling 0 to 1, andgit hash-objectdiffered from the HEAD blob), and the restore leg was confirmed the same way (hash identical to the HEAD blob, counts back to 1 and 0,git diff HEADempty). No rebuild leg is owed: the pins import the subject as./engine.js, a same-package relative specifier vitest resolves tosrc, notdist.2 failed | 1 passedwith those same two assertions.pnpm --filter @objectstack/service-automation test— 120 files, 1422 tests, all passing.pnpm --filter @objectstack/service-automation typecheck— clean, andcheck:test-typecheckreports the test layer compiling with 0 files / 0 errors in the ledger. Neither tsconfig in this package excludes*.test.ts, so the new pin file is inside both programs.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, which read the change set from git itself (5 paths) and printed 56 commands. All 56 run, each exit code captured immediately after a single redirection with no pipe anywhere. 54 were green first time. Two returned exit 3, PREREQUISITE NOT MET, and were converted into real measurements rather than reported as passes:check:dual-build-cjs-loadsneeded the built closure (green afterturbo run buildover the packages), andcheck:type-check-debtneeded the heap ceilinglint.ymldeclares for that step,--max-old-space-size=6144(green at that ceiling; at 4096 its tsc re-measure died of heap exhaustion, which the gate correctly refuses to record as zero).check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity— were run rather than read as silent. All green.pnpm lintover the whole repository — green. No narrowing claimed and none needed.A changeset is included: this changes runtime behaviour a flow author can observe, so it is not a
skip-changesetshape.🤖 Generated with Claude Code
Generated by Claude Code